// Copyright (C) 2008 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package com.google.gerrit.reviewdb.client;

import static com.google.gerrit.reviewdb.client.RefNames.REFS_CHANGES;

import com.google.gerrit.extensions.client.ChangeStatus;
import com.google.gwtorm.client.Column;
import com.google.gwtorm.client.IntKey;
import com.google.gwtorm.client.RowVersion;
import com.google.gwtorm.client.StringKey;

import java.sql.Timestamp;
import java.util.Arrays;

/**
 * A change proposed to be merged into a {@link Branch}.
 * <p>
 * The data graph rooted below a Change can be quite complex:
 *
 * <pre>
 *   {@link Change}
 *     |
 *     +- {@link ChangeMessage}: &quot;cover letter&quot; or general comment.
 *     |
 *     +- {@link PatchSet}: a single variant of this change.
 *          |
 *          +- {@link PatchSetApproval}: a +/- vote on the change's current state.
 *          |
 *          +- {@link PatchLineComment}: comment about a specific line
 * </pre>
 * <p>
 * <h5>PatchSets</h5>
 * <p>
 * Every change has at least one PatchSet. A change starts out with one
 * PatchSet, the initial proposal put forth by the change owner. This
 * {@link Account} is usually also listed as the author and committer in the
 * PatchSetInfo.
 * <p>
 * Each PatchSet contains zero or more Patch records, detailing the file paths
 * impacted by the change (otherwise known as, the file paths the author
 * added/deleted/modified). Sometimes a merge commit can contain zero patches,
 * if the merge has no conflicts, or has no impact other than to cut off a line
 * of development.
 * <p>
 * Each PatchLineComment is a draft or a published comment about a single line
 * of the associated file. These are the inline comment entities created by
 * users as they perform a review.
 * <p>
 * When additional PatchSets appear under a change, these PatchSets reference
 * <i>replacement</i> commits; alternative commits that could be made to the
 * project instead of the original commit referenced by the first PatchSet.
 * <p>
 * A change has at most one current PatchSet. The current PatchSet is updated
 * when a new replacement PatchSet is uploaded. When a change is submitted, the
 * current patch set is what is merged into the destination branch.
 * <p>
 * <h5>ChangeMessage</h5>
 * <p>
 * The ChangeMessage entity is a general free-form comment about the whole
 * change, rather than PatchLineComment's file and line specific context. The
 * ChangeMessage appears at the start of any email generated by Gerrit, and is
 * shown on the change overview page, rather than in a file-specific context.
 * Users often use this entity to describe general remarks about the overall
 * concept proposed by the change.
 * <p>
 * <h5>PatchSetApproval</h5>
 * <p>
 * PatchSetApproval entities exist to fill in the <i>cells</i> of the approvals
 * table in the web UI. That is, a single PatchSetApproval record's key is the
 * tuple {@code (PatchSet,Account,ApprovalCategory)}. Each PatchSetApproval
 * carries with it a small score value, typically within the range -2..+2.
 * <p>
 * If an Account has created only PatchSetApprovals with a score value of 0, the
 * Change shows in their dashboard, and they are said to be CC'd (carbon copied)
 * on the Change, but are not a direct reviewer. This often happens when an
 * account was specified at upload time with the {@code --cc} command line flag,
 * or have published comments, but left the approval scores at 0 ("No Score").
 * <p>
 * If an Account has one or more PatchSetApprovals with a score != 0, the Change
 * shows in their dashboard, and they are said to be an active reviewer. Such
 * individuals are highlighted when notice of a replacement patch set is sent,
 * or when notice of the change submission occurs.
 */
public final class Change {
  public static class Id extends IntKey<com.google.gwtorm.client.Key<?>> {
    private static final long serialVersionUID = 1L;

    @Column(id = 1)
    public int id;

    protected Id() {
    }

    public Id(final int id) {
      this.id = id;
    }

    @Override
    public int get() {
      return id;
    }

    @Override
    protected void set(int newValue) {
      id = newValue;
    }

    public String toRefPrefix() {
      return refPrefixBuilder().toString();
    }

    StringBuilder refPrefixBuilder() {
      StringBuilder r = new StringBuilder(32)
         .append(REFS_CHANGES);
      int m = id % 100;
      if (m < 10) {
        r.append('0');
      }
      return r.append(m)
          .append('/')
          .append(id)
          .append('/');
    }

    /** Parse a Change.Id out of a string representation. */
    public static Id parse(final String str) {
      final Id r = new Id();
      r.fromString(str);
      return r;
    }

    public static Id fromRef(String ref) {
      int cs = startIndex(ref);
      if (cs < 0) {
        return null;
      }
      int ce = nextNonDigit(ref, cs);
      if (ref.substring(ce).equals(RefNames.META_SUFFIX)
          || PatchSet.Id.fromRef(ref, ce) >= 0) {
        return new Change.Id(Integer.parseInt(ref.substring(cs, ce)));
      }
      return null;
    }

    public static Id fromEditRefPart(String ref) {
      int startChangeId = ref.indexOf(RefNames.EDIT_PREFIX) +
          RefNames.EDIT_PREFIX.length();
      int endChangeId = nextNonDigit(ref, startChangeId);
      String id = ref.substring(startChangeId, endChangeId);
      return new Change.Id(Integer.parseInt(id));
    }

    static int startIndex(String ref) {
      if (ref == null || !ref.startsWith(REFS_CHANGES)) {
        return -1;
      }

      // Last 2 digits.
      int ls = REFS_CHANGES.length();
      int le = nextNonDigit(ref, ls);
      if (le - ls != 2 || le >= ref.length() || ref.charAt(le) != '/') {
        return -1;
      }

      // Change ID.
      int cs = le + 1;
      if (cs >= ref.length() || ref.charAt(cs) == '0') {
        return -1;
      }
      int ce = nextNonDigit(ref, cs);
      if (ce >= ref.length() || ref.charAt(ce) != '/') {
        return -1;
      }
      switch (ce - cs) {
        case 0:
          return -1;
        case 1:
          if (ref.charAt(ls) != '0'
              || ref.charAt(ls + 1) != ref.charAt(cs)) {
            return -1;
          }
          break;
        default:
          if (ref.charAt(ls) != ref.charAt(ce - 2)
              || ref.charAt(ls + 1) != ref.charAt(ce - 1)) {
            return -1;
          }
          break;
      }
      return cs;
    }

    static int nextNonDigit(String s, int i) {
      while (i < s.length() && s.charAt(i) >= '0' && s.charAt(i) <= '9') {
        i++;
      }
      return i;
    }
  }

  /** Globally unique identification of this change. */
  public static class Key extends StringKey<com.google.gwtorm.client.Key<?>> {
    private static final long serialVersionUID = 1L;

    @Column(id = 1, length = 60)
    protected String id;

    protected Key() {
    }

    public Key(final String id) {
      this.id = id;
    }

    @Override
    public String get() {
      return id;
    }

    @Override
    protected void set(String newValue) {
      id = newValue;
    }

    /** Construct a key that is after all keys prefixed by this key. */
    public Key max() {
      final StringBuilder revEnd = new StringBuilder(get().length() + 1);
      revEnd.append(get());
      revEnd.append('\u9fa5');
      return new Key(revEnd.toString());
    }

    /** Obtain a shorter version of this key string, using a leading prefix. */
    public String abbreviate() {
      final String s = get();
      return s.substring(0, Math.min(s.length(), 9));
    }

    /** Parse a Change.Key out of a string representation. */
    public static Key parse(final String str) {
      final Key r = new Key();
      r.fromString(str);
      return r;
    }
  }

  /** Minimum database status constant for an open change. */
  private static final char MIN_OPEN = 'a';
  /** Database constant for {@link Status#NEW}. */
  public static final char STATUS_NEW = 'n';
  /** Database constant for {@link Status#DRAFT}. */
  public static final char STATUS_DRAFT = 'd';
  /** Maximum database status constant for an open change. */
  private static final char MAX_OPEN = 'z';

  /** Database constant for {@link Status#MERGED}. */
  public static final char STATUS_MERGED = 'M';

  /** ID number of the first patch set in a change. */
  public static final int INITIAL_PATCH_SET_ID = 1;

  /**
   * Current state within the basic workflow of the change.
   *
   * <p>
   * Within the database, lower case codes ('a'..'z') indicate a change that is
   * still open, and that can be modified/refined further, while upper case
   * codes ('A'..'Z') indicate a change that is closed and cannot be further
   * modified.
   * */
  public enum Status {
    /**
     * Change is open and pending review, or review is in progress.
     *
     * <p>
     * This is the default state assigned to a change when it is first created
     * in the database. A change stays in the NEW state throughout its review
     * cycle, until the change is submitted or abandoned.
     *
     * <p>
     * Changes in the NEW state can be moved to:
     * <ul>
     * <li>{@link #MERGED} - when the Submit Patch Set action is used;
     * <li>{@link #ABANDONED} - when the Abandon action is used.
     * </ul>
     */
    NEW(STATUS_NEW, ChangeStatus.NEW),

    /**
     * Change is a draft change that only consists of draft patchsets.
     *
     * <p>
     * This is a change that is not meant to be submitted or reviewed yet. If
     * the uploader publishes the change, it becomes a NEW change.
     * Publishing is a one-way action, a change cannot return to DRAFT status.
     * Draft changes are only visible to the uploader and those explicitly
     * added as reviewers.
     *
     * <p>
     * Changes in the DRAFT state can be moved to:
     * <ul>
     * <li>{@link #NEW} - when the change is published, it becomes a new change;
     * </ul>
     */
    DRAFT(STATUS_DRAFT, ChangeStatus.DRAFT),

    /**
     * Change is closed, and submitted to its destination branch.
     *
     * <p>
     * Once a change has been merged, it cannot be further modified by adding a
     * replacement patch set. Draft comments however may be published,
     * supporting a post-submit review.
     */
    MERGED(STATUS_MERGED, ChangeStatus.MERGED),

    /**
     * Change is closed, but was not submitted to its destination branch.
     *
     * <p>
     * Once a change has been abandoned, it cannot be further modified by adding
     * a replacement patch set, and it cannot be merged. Draft comments however
     * may be published, permitting reviewers to send constructive feedback.
     */
    ABANDONED('A', ChangeStatus.ABANDONED);

    static {
      boolean ok = true;
      if (Status.values().length != ChangeStatus.values().length) {
        ok = false;
      }
      for (Status s : Status.values()) {
        ok &= s.name().equals(s.changeStatus.name());
      }
      if (!ok) {
        throw new IllegalStateException("Mismatched status mapping: "
            + Arrays.asList(Status.values()) + " != "
            + Arrays.asList(ChangeStatus.values()));
      }
    }

    private final char code;
    private final boolean closed;
    private final ChangeStatus changeStatus;

    Status(char c, ChangeStatus cs) {
      code = c;
      closed = !(MIN_OPEN <= c && c <= MAX_OPEN);
      changeStatus = cs;
    }

    public char getCode() {
      return code;
    }

    public boolean isOpen() {
      return !closed;
    }

    public boolean isClosed() {
      return closed;
    }

    public ChangeStatus asChangeStatus() {
      return changeStatus;
    }

    public static Status forCode(final char c) {
      for (final Status s : Status.values()) {
        if (s.code == c) {
          return s;
        }
      }
      return null;
    }

    public static Status forChangeStatus(ChangeStatus cs) {
      for (Status s : Status.values()) {
        if (s.changeStatus == cs) {
          return s;
        }
      }
      return null;
    }
  }

  /** Locally assigned unique identifier of the change */
  @Column(id = 1)
  protected Id changeId;

  /** Globally assigned unique identifier of the change */
  @Column(id = 2)
  protected Key changeKey;

  /** optimistic locking */
  @Column(id = 3)
  @RowVersion
  protected int rowVersion;

  /** When this change was first introduced into the database. */
  @Column(id = 4)
  protected Timestamp createdOn;

  /**
   * When was a meaningful modification last made to this record's data
   * <p>
   * Note, this update timestamp includes its children.
   */
  @Column(id = 5)
  protected Timestamp lastUpdatedOn;

  // DELETED: id = 6 (sortkey)

  @Column(id = 7, name = "owner_account_id")
  protected Account.Id owner;

  /** The branch (and project) this change merges into. */
  @Column(id = 8)
  protected Branch.NameKey dest;

  // DELETED: id = 9 (open)

  /** Current state code; see {@link Status}. */
  @Column(id = 10)
  protected char status;

  // DELETED: id = 11 (nbrPatchSets)

  /** The current patch set. */
  @Column(id = 12)
  protected int currentPatchSetId;

  /** Subject from the current patch set. */
  @Column(id = 13)
  protected String subject;

  /** Topic name assigned by the user, if any. */
  @Column(id = 14, notNull = false)
  protected String topic;

  // DELETED: id = 15 (lastSha1MergeTested)
  // DELETED: id = 16 (mergeable)

  /**
   * First line of first patch set's commit message.
   * <p>
   * Unlike {@link #subject}, this string does not change if future patch sets
   * change the first line.
   */
  @Column(id = 17, notNull = false)
  protected String originalSubject;

  /**
   * Unique id for the changes submitted together assigned during merging.
   * Only set if the status is MERGED.
   */
  @Column(id = 18, notNull = false)
  protected String submissionId;

  /** @see com.google.gerrit.server.notedb.NoteDbChangeState */
  @Column(id = 101, notNull = false, length = Integer.MAX_VALUE)
  protected String noteDbState;

  protected Change() {
  }

  public Change(Change.Key newKey, Change.Id newId, Account.Id ownedBy,
      Branch.NameKey forBranch, Timestamp ts) {
    changeKey = newKey;
    changeId = newId;
    createdOn = ts;
    lastUpdatedOn = createdOn;
    owner = ownedBy;
    dest = forBranch;
    setStatus(Status.NEW);
  }

  public Change(Change other) {
    changeId = other.changeId;
    changeKey = other.changeKey;
    rowVersion = other.rowVersion;
    createdOn = other.createdOn;
    lastUpdatedOn = other.lastUpdatedOn;
    owner = other.owner;
    dest = other.dest;
    status = other.status;
    currentPatchSetId = other.currentPatchSetId;
    subject = other.subject;
    originalSubject = other.originalSubject;
    submissionId = other.submissionId;
    topic = other.topic;
    noteDbState = other.noteDbState;
  }

  /** Legacy 32 bit integer identity for a change. */
  public Change.Id getId() {
    return changeId;
  }

  /** Legacy 32 bit integer identity for a change. */
  public int getChangeId() {
    return changeId.get();
  }

  /** The Change-Id tag out of the initial commit, or a natural key. */
  public Change.Key getKey() {
    return changeKey;
  }

  public void setKey(final Change.Key k) {
    changeKey = k;
  }

  public Timestamp getCreatedOn() {
    return createdOn;
  }

  public void setCreatedOn(Timestamp ts) {
    createdOn = ts;
  }

  public Timestamp getLastUpdatedOn() {
    return lastUpdatedOn;
  }

  public void setLastUpdatedOn(Timestamp now) {
    lastUpdatedOn = now;
  }

  public int getRowVersion() {
    return rowVersion;
  }

  public Account.Id getOwner() {
    return owner;
  }

  public void setOwner(Account.Id owner) {
    this.owner = owner;
  }

  public Branch.NameKey getDest() {
    return dest;
  }

  public void setDest(Branch.NameKey dest) {
    this.dest = dest;
  }

  public Project.NameKey getProject() {
    return dest.getParentKey();
  }

  public String getSubject() {
    return subject;
  }

  public String getOriginalSubject() {
    return originalSubject != null ? originalSubject : subject;
  }

  /** Get the id of the most current {@link PatchSet} in this change. */
  public PatchSet.Id currentPatchSetId() {
    if (currentPatchSetId > 0) {
      return new PatchSet.Id(changeId, currentPatchSetId);
    }
    return null;
  }

  public void setCurrentPatchSet(final PatchSetInfo ps) {
    if (originalSubject == null && subject != null) {
      // Change was created before schema upgrade. Use the last subject
      // associated with this change, as the most recent discussion will
      // be under that thread in an email client such as GMail.
      originalSubject = subject;
    }

    currentPatchSetId = ps.getKey().get();
    subject = ps.getSubject();

    if (originalSubject == null) {
      // Newly created changes remember the first commit's subject.
      originalSubject = subject;
    }
  }

  public void setCurrentPatchSet(PatchSet.Id psId, String subject,
      String originalSubject) {
    if (!psId.getParentKey().equals(changeId)) {
      throw new IllegalArgumentException(
          "patch set ID " + psId + " is not for change " + changeId);
    }
    currentPatchSetId = psId.get();
    this.subject = subject;
    this.originalSubject = originalSubject;
  }

  public void clearCurrentPatchSet() {
    currentPatchSetId = 0;
    subject = null;
    originalSubject = null;
  }

  public String getSubmissionId() {
    return submissionId;
  }

  public void setSubmissionId(String id) {
    this.submissionId = id;
  }

  public Status getStatus() {
    return Status.forCode(status);
  }

  public void setStatus(Status newStatus) {
    status = newStatus.getCode();
  }

  public String getTopic() {
    return topic;
  }

  public void setTopic(String topic) {
    this.topic = topic;
  }

  public String getNoteDbState() {
    return noteDbState;
  }

  public void setNoteDbState(String state) {
    noteDbState = state;
  }

  @Override
  public String toString() {
    return new StringBuilder(getClass().getSimpleName())
        .append('{').append(changeId)
        .append(" (").append(changeKey).append("), ")
        .append("dest=").append(dest).append(", ")
        .append("status=").append(status).append('}')
        .toString();
  }
}
